[id].vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. <template>
  2. <div v-if="workflow" class="flex h-screen">
  3. <div
  4. v-if="state.showSidebar"
  5. class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
  6. >
  7. <workflow-edit-block
  8. v-if="editState.editing"
  9. v-model:autocomplete="autocompleteState.cache"
  10. :data="editState.blockData"
  11. :data-changed="autocompleteState.dataChanged"
  12. :workflow="workflow"
  13. :editor="editor"
  14. @update="updateBlockData"
  15. @close="(editState.editing = false), (editState.blockData = {})"
  16. />
  17. <workflow-details-card
  18. v-else
  19. :workflow="workflow"
  20. @update="updateWorkflow"
  21. />
  22. </div>
  23. <div class="flex-1 relative overflow-auto">
  24. <div
  25. class="absolute w-full flex items-center z-10 left-0 p-4 top-0 pointer-events-none"
  26. >
  27. <ui-tabs
  28. v-model="state.activeTab"
  29. class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 pointer-events-auto"
  30. >
  31. <button
  32. v-tooltip="
  33. `${t('workflow.toggleSidebar')} (${
  34. shortcut['editor:toggle-sidebar'].readable
  35. })`
  36. "
  37. style="margin-right: 6px"
  38. @click="toggleSidebar"
  39. >
  40. <v-remixicon
  41. :name="state.showSidebar ? 'riSideBarFill' : 'riSideBarLine'"
  42. />
  43. </button>
  44. <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
  45. <ui-tab value="logs" class="flex items-center">
  46. {{ t('common.log', 2) }}
  47. <span
  48. v-if="workflowStore.states.length > 0"
  49. class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
  50. style="min-width: 25px"
  51. >
  52. {{ workflowStore.states.length }}
  53. </span>
  54. </ui-tab>
  55. </ui-tabs>
  56. <div class="flex-grow pointer-events-none" />
  57. <editor-local-actions
  58. :editor="editor"
  59. :workflow="workflow"
  60. :is-data-changed="state.dataChanged"
  61. @save="state.dataChanged = false"
  62. @modal="(modalState.name = $event), (modalState.show = true)"
  63. />
  64. </div>
  65. <ui-tab-panels
  66. v-model="state.activeTab"
  67. class="overflow-hidden h-full w-full"
  68. @drop="onDropInEditor"
  69. @dragend="clearHighlightedElements"
  70. @dragover.prevent="onDragoverEditor"
  71. >
  72. <ui-tab-panel cache value="editor" class="w-full">
  73. <workflow-editor
  74. v-if="state.workflowConverted"
  75. :id="route.params.id"
  76. :data="workflow.drawflow"
  77. class="h-screen"
  78. @init="onEditorInit"
  79. @edit="initEditBlock"
  80. @update:node="state.dataChanged = true"
  81. @delete:node="state.dataChanged = true"
  82. />
  83. <editor-local-ctx-menu
  84. v-if="editor"
  85. :editor="editor"
  86. @copy="copySelectedElements"
  87. @paste="pasteCopiedElements"
  88. @duplicate="duplicateElements"
  89. />
  90. </ui-tab-panel>
  91. <ui-tab-panel value="logs" class="mt-24">
  92. <editor-logs
  93. :workflow-id="route.params.id"
  94. :workflow-states="workflowStore.states"
  95. />
  96. </ui-tab-panel>
  97. </ui-tab-panels>
  98. </div>
  99. </div>
  100. <ui-modal
  101. v-model="modalState.show"
  102. :content-class="activeWorkflowModal?.width || 'max-w-xl'"
  103. v-bind="activeWorkflowModal.attrs || {}"
  104. >
  105. <template v-if="activeWorkflowModal.title" #header>
  106. {{ activeWorkflowModal.title }}
  107. <a
  108. v-if="activeWorkflowModal.docs"
  109. :title="t('common.docs')"
  110. :href="activeWorkflowModal.docs"
  111. target="_blank"
  112. class="inline-block align-middle"
  113. >
  114. <v-remixicon name="riInformationLine" size="20" />
  115. </a>
  116. </template>
  117. <component
  118. :is="activeWorkflowModal.component"
  119. v-bind="{ workflow }"
  120. v-on="activeWorkflowModal?.events || {}"
  121. @update="updateWorkflow"
  122. @close="modalState.show = false"
  123. />
  124. </ui-modal>
  125. </template>
  126. <script setup>
  127. import {
  128. reactive,
  129. computed,
  130. onMounted,
  131. shallowRef,
  132. onBeforeUnmount,
  133. } from 'vue';
  134. import { useI18n } from 'vue-i18n';
  135. import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
  136. import { customAlphabet } from 'nanoid';
  137. import { useStore } from '@/stores/main';
  138. import { useUserStore } from '@/stores/user';
  139. import { useWorkflowStore } from '@/stores/workflow';
  140. import { useShortcut } from '@/composable/shortcut';
  141. import { tasks } from '@/utils/shared';
  142. import { debounce, parseJSON, throttle } from '@/utils/helper';
  143. import { fetchApi } from '@/utils/api';
  144. import EditorUtils from '@/utils/EditorUtils';
  145. import convertWorkflowData from '@/utils/convertWorkflowData';
  146. import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
  147. import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
  148. import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
  149. import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
  150. import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
  151. import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
  152. import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
  153. import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
  154. import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
  155. import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
  156. const nanoid = customAlphabet('1234567890abcdef', 7);
  157. const { t } = useI18n();
  158. const store = useStore();
  159. const route = useRoute();
  160. const router = useRouter();
  161. const userStore = useUserStore();
  162. const workflowStore = useWorkflowStore();
  163. /* eslint-disable-next-line */
  164. const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
  165. const editor = shallowRef(null);
  166. const state = reactive({
  167. showSidebar: true,
  168. dataChanged: false,
  169. workflowConverted: false,
  170. activeTab: route.query.tab || 'editor',
  171. });
  172. const modalState = reactive({
  173. name: '',
  174. show: false,
  175. });
  176. const editState = reactive({
  177. blockData: {},
  178. editing: false,
  179. });
  180. const autocompleteState = reactive({
  181. cache: new Map(),
  182. dataChanged: false,
  183. });
  184. const workflowPayload = {
  185. data: {},
  186. isUpdating: false,
  187. };
  188. const workflowModals = {
  189. table: {
  190. icon: 'riKey2Line',
  191. width: 'max-w-2xl',
  192. component: WorkflowDataTable,
  193. title: t('workflow.table.title'),
  194. docs: 'https://docs.automa.site/api-reference/table.html',
  195. },
  196. 'workflow-share': {
  197. icon: 'riShareLine',
  198. component: WorkflowShare,
  199. attrs: {
  200. blur: true,
  201. persist: true,
  202. customContent: true,
  203. },
  204. events: {
  205. close() {
  206. modalState.show = false;
  207. modalState.name = '';
  208. },
  209. publish() {
  210. modalState.show = false;
  211. modalState.name = '';
  212. },
  213. },
  214. },
  215. 'global-data': {
  216. width: 'max-w-2xl',
  217. icon: 'riDatabase2Line',
  218. component: WorkflowGlobalData,
  219. title: t('common.globalData'),
  220. docs: 'https://docs.automa.site/api-reference/global-data.html',
  221. },
  222. settings: {
  223. width: 'max-w-2xl',
  224. icon: 'riSettings3Line',
  225. component: WorkflowSettings,
  226. title: t('common.settings'),
  227. attrs: {
  228. customContent: true,
  229. },
  230. events: {
  231. close() {
  232. modalState.show = false;
  233. modalState.name = '';
  234. },
  235. },
  236. },
  237. };
  238. const workflow = computed(() =>
  239. workflowStore.getById('local', route.params.id)
  240. );
  241. const activeWorkflowModal = computed(
  242. () => workflowModals[modalState.name] || {}
  243. );
  244. const updateBlockData = debounce((data) => {
  245. const node = editor.value.getNode.value(editState.blockData.blockId);
  246. node.data = data;
  247. state.dataChanged = true;
  248. // let payload = data;
  249. // state.blockData.data = data;
  250. // state.dataChange = true;
  251. // autocomplete.dataChanged = true;
  252. // if (state.blockData.isInGroup) {
  253. // payload = { itemId: state.blockData.itemId, data };
  254. // } else {
  255. // editor.value.updateNodeDataFromId(state.blockData.blockId, data);
  256. // }
  257. // const inputEl = document.querySelector(
  258. // `#node-${state.blockData.blockId} input.trigger`
  259. // );
  260. // if (inputEl)
  261. // inputEl.dispatchEvent(
  262. // new CustomEvent('change', { detail: toRaw(payload) })
  263. // );
  264. }, 250);
  265. const updateHostedWorkflow = throttle(async () => {
  266. if (!userStore.user || workflowPayload.isUpdating) return;
  267. const isHosted = workflowStore.userHosted[route.param.id];
  268. const isBackup = (userStore.backupIds || []).includes(route.params.id);
  269. const isExists = Boolean(workflow.value);
  270. if (
  271. (!isBackup && !isHosted) ||
  272. !isExists ||
  273. Object.keys(workflowPayload.data).length === 0
  274. )
  275. return;
  276. workflowPayload.isUpdating = true;
  277. const delKeys = [
  278. 'id',
  279. 'pass',
  280. 'logs',
  281. 'trigger',
  282. 'createdAt',
  283. 'isDisabled',
  284. 'isProtected',
  285. ];
  286. delKeys.forEach((key) => {
  287. delete workflowPayload.data[key];
  288. });
  289. try {
  290. if (typeof workflowPayload.data.drawflow === 'string') {
  291. workflowPayload.data.drawflow = parseJSON(
  292. workflowPayload.data.drawflow,
  293. workflowPayload.data.drawflow
  294. );
  295. }
  296. const response = await fetchApi(`/me/workflows/${route.params.id}`, {
  297. method: 'PUT',
  298. keepalive: true,
  299. body: JSON.stringify({
  300. workflow: workflowPayload.data,
  301. }),
  302. });
  303. if (!response.ok) throw new Error(response.message);
  304. if (isBackup) {
  305. const result = await response.json();
  306. if (result.updatedAt) {
  307. await browser.storage.local.set({ lastBackup: result.updatedAt });
  308. }
  309. }
  310. workflowPayload.data = {};
  311. workflowPayload.isUpdating = false;
  312. } catch (error) {
  313. console.error(error);
  314. workflowPayload.isUpdating = false;
  315. }
  316. }, 5000);
  317. function toggleSidebar() {
  318. state.showSidebar = !state.showSidebar;
  319. localStorage.setItem('workflow:sidebar', state.showSidebar);
  320. }
  321. function initEditBlock(data) {
  322. const { editComponent } = tasks[data.id];
  323. editState.editing = true;
  324. editState.blockData = { ...data, editComponent };
  325. }
  326. function updateWorkflow(data) {
  327. workflowStore.updateWorkflow({
  328. data,
  329. location: 'local',
  330. id: route.params.id,
  331. });
  332. workflowPayload.data = { ...workflowPayload.data, ...data };
  333. }
  334. function onEditorInit(instance) {
  335. editor.value = instance;
  336. // listen to change event
  337. instance.onEdgesChange((changes) => {
  338. changes.forEach(({ type }) => {
  339. if (state.dataChanged) return;
  340. state.dataChanged = type !== 'select';
  341. });
  342. });
  343. instance.onEdgeDoubleClick(({ edge }) => {
  344. instance.removeEdges([edge]);
  345. });
  346. }
  347. function clearHighlightedElements() {
  348. const elements = document.querySelectorAll(
  349. '.dropable-area__node, .dropable-area__handle'
  350. );
  351. elements.forEach((element) => {
  352. element.classList.remove('dropable-area__node');
  353. element.classList.remove('dropable-area__handle');
  354. });
  355. }
  356. function toggleHighlightElement({ target, elClass, classes }) {
  357. const targetEl = target.closest(elClass);
  358. if (targetEl) {
  359. targetEl.classList.add(classes);
  360. } else {
  361. const elements = document.querySelectorAll(`.${classes}`);
  362. elements.forEach((element) => {
  363. element.classList.remove(classes);
  364. });
  365. }
  366. }
  367. function onDragoverEditor({ target }) {
  368. toggleHighlightElement({
  369. target,
  370. elClass: '.vue-flow__handle.source',
  371. classes: 'dropable-area__handle',
  372. });
  373. if (!target.closest('.vue-flow__handle')) {
  374. toggleHighlightElement({
  375. target,
  376. elClass: '.vue-flow__node:not(.vue-flow__node-BlockGroup)',
  377. classes: 'dropable-area__node',
  378. });
  379. }
  380. }
  381. function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
  382. const block = parseJSON(dataTransfer.getData('block'), null);
  383. if (!block) return;
  384. clearHighlightedElements();
  385. const nodeEl = EditorUtils.isNode(target);
  386. if (nodeEl) {
  387. EditorUtils.replaceNode(editor.value, { block, target: nodeEl });
  388. return;
  389. }
  390. const isTriggerExists =
  391. block.id === 'trigger' &&
  392. editor.value.getNodes.value.some((node) => node.label === 'trigger');
  393. if (isTriggerExists) return;
  394. const position = editor.value.project({ x: clientX - 360, y: clientY - 18 });
  395. const newNode = {
  396. position,
  397. id: nanoid(),
  398. label: block.id,
  399. data: block.data,
  400. type: block.component,
  401. };
  402. editor.value.addNodes([newNode]);
  403. const edgeEl = EditorUtils.isEdge(target);
  404. const handleEl = EditorUtils.isHandle(target);
  405. if (handleEl) {
  406. EditorUtils.appendNode(editor.value, {
  407. target: handleEl,
  408. nodeId: newNode.id,
  409. });
  410. } else if (edgeEl) {
  411. EditorUtils.insertBetweenNode(editor.value, {
  412. target: edgeEl,
  413. nodeId: newNode.id,
  414. outputs: block.outputs,
  415. });
  416. }
  417. if (block.fromGroup) {
  418. setTimeout(() => {
  419. const blockEl = document.querySelector(`[data-id="${newNode.id}"]`);
  420. blockEl?.setAttribute('group-item-id', block.itemId);
  421. }, 200);
  422. }
  423. state.dataChanged = true;
  424. }
  425. function copyElements(nodes, edges, initialPos) {
  426. const newIds = new Map();
  427. let firstNodePos = null;
  428. const newNodes = nodes.map(({ id, label, position, data, type }, index) => {
  429. const newNodeId = nanoid();
  430. const nodePos = {
  431. z: position.z || 0,
  432. y: position.y + 50,
  433. x: position.x + 50,
  434. };
  435. newIds.set(id, newNodeId);
  436. if (initialPos) {
  437. if (index === 0) {
  438. firstNodePos = {
  439. x: nodePos.x,
  440. y: nodePos.y,
  441. };
  442. initialPos = editor.value.project({
  443. y: initialPos.clientY,
  444. x: initialPos.clientX - 360,
  445. });
  446. Object.assign(nodePos, initialPos);
  447. } else {
  448. const xDistance = nodePos.x - firstNodePos.x;
  449. const yDistance = nodePos.y - firstNodePos.y;
  450. nodePos.x = initialPos.x + xDistance;
  451. nodePos.y = initialPos.y + yDistance;
  452. }
  453. }
  454. return {
  455. type,
  456. data,
  457. label,
  458. id: newNodeId,
  459. selected: true,
  460. position: nodePos,
  461. };
  462. });
  463. const newEdges = edges.reduce(
  464. (acc, { target, targetHandle, source, sourceHandle }) => {
  465. const targetId = newIds.get(target);
  466. const sourceId = newIds.get(source);
  467. if (!targetId || !sourceId) return acc;
  468. acc.push({
  469. selected: true,
  470. target: targetId,
  471. source: sourceId,
  472. id: `edge-${nanoid()}`,
  473. targetHandle: targetHandle.replace(target, targetId),
  474. sourceHandle: sourceHandle.replace(source, sourceId),
  475. });
  476. return acc;
  477. },
  478. []
  479. );
  480. return {
  481. nodes: newNodes,
  482. edges: newEdges,
  483. };
  484. }
  485. function duplicateElements({ nodes, edges }) {
  486. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  487. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  488. const { edges: newEdges, nodes: newNodes } = copyElements(nodes, edges);
  489. editor.value.addNodes(newNodes);
  490. editor.value.addEdges(newEdges);
  491. }
  492. function copySelectedElements(data = {}) {
  493. store.copiedEls.nodes = data.nodes || editor.value.getSelectedNodes.value;
  494. store.copiedEls.edges = data.edges || editor.value.getSelectedEdges.value;
  495. }
  496. function pasteCopiedElements(position) {
  497. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  498. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  499. const { nodes, edges } = copyElements(
  500. store.copiedEls.nodes,
  501. store.copiedEls.edges,
  502. position
  503. );
  504. editor.value.addNodes(nodes);
  505. editor.value.addEdges(edges);
  506. }
  507. function onKeydown({ ctrlKey, metaKey, key }) {
  508. const command = (keyName) => (ctrlKey || metaKey) && keyName === key;
  509. if (command('c')) {
  510. copySelectedElements();
  511. console.log(store.copiedEls);
  512. } else if (command('v')) {
  513. pasteCopiedElements();
  514. }
  515. }
  516. /* eslint-disable consistent-return */
  517. onBeforeRouteLeave(() => {
  518. updateHostedWorkflow();
  519. if (!state.dataChange) return;
  520. const confirm = window.confirm(t('message.notSaved'));
  521. if (!confirm) return false;
  522. });
  523. onMounted(() => {
  524. if (!workflow.value) {
  525. router.replace('/');
  526. return null;
  527. }
  528. state.showSidebar =
  529. JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
  530. const convertedData = convertWorkflowData(workflow.value);
  531. updateWorkflow({ drawflow: convertedData.drawflow });
  532. state.workflowConverted = true;
  533. window.onbeforeunload = () => {
  534. updateHostedWorkflow();
  535. if (state.dataChange) {
  536. return t('message.notSaved');
  537. }
  538. return true;
  539. };
  540. window.addEventListener('keydown', onKeydown);
  541. });
  542. onBeforeUnmount(() => {
  543. window.onbeforeunload = null;
  544. window.removeEventListener('keydown', onKeydown);
  545. });
  546. </script>
  547. <style>
  548. .vue-flow,
  549. .editor-tab {
  550. width: 100%;
  551. height: 100%;
  552. }
  553. .vue-flow__node {
  554. @apply rounded-lg;
  555. }
  556. .dropable-area__node,
  557. .dropable-area__handle {
  558. @apply ring-4;
  559. }
  560. </style>